#!/usr/bin/env python
##################################################################################################
#
# Stupid 'p4-like' interface for Subversion
#
# version 2008/08/17
#
# Mitch Haile
# mitch@biscade.com
# http://www.biscade.com/tools/
#
# This just implements the commands that I use and the flags I wanted.  No real attempt to 
# implement the entire p4 command line is made. :-)  Don't expect 'p4 resolve' or 'p4 integrate'
# any time soon (or ever).
#
# No attempt is made for this code to be 'pythonic' either.  If you have suggestions or bug
# fixes, let me know.  But don't flame me for not being properly pythonic.
#
# Constructive feedback always welcome.  
#
##################################################################################################
# 
# Install:
#
#   1. Place this somewhere in your PATH.  I use ~/bin/, but /usr/local/bin or elsewhere is fine.
#
#   2. Set the +x bit with chmod.
#
#   3. Set any alias or symlinks you might want (I use a p4 symlink).
#
#   4. I use $TOP to represent the top of my source tree.  You may want to change TOP_VARIABLE
#      to whatever you use, if you want this tool to find the top of your tree and provide info
#      based on the top of the tree rather than the current directory.
#
# I have only tested this with Python 2.5.1 at this time.
#
##################################################################################################
#
# Acknowledgements:
#
#    Thanks to Chuck R for his example patch for 's4 describe -s' support.
#
##################################################################################################
# 
# Copyright (C) 2008 Mitch Haile
#
# This program is free software; you can redistribute it and/or modify it under the terms of
# the GNU General Public License Version 2 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with this program; if
# not, see <http://www.gnu.org/licenses>.
#
##################################################################################################

import getopt
import sys
import os
import xml.sax
import xml.sax.handler

TOP_VARIABLE = "TOP"

##################################################################################################
## Subversion output parsing code 
##################################################################################################

class svnLogEntry:
    def __init__(self):
        self.revision = -1
        self.author = None
        self.date = None
        self.msg = None

class svnStatusEntry:
    def __init__(self):
        self.path = None
        self.revision = -1
        self.action = None

#
# Format results from 'svn log --xml'.  Records look like this:
#
# <logentry
#    revision="1">
# <author>mitch</author>
# <date>2007-09-07T11:26:38.313604Z</date>
# <msg>hello message.</msg>
# </logentry>
#
class svnLogXmlHandler(xml.sax.handler.ContentHandler):
    def __init__(self):
        self.entry = svnLogEntry()
        self.in_author = False
        self.in_date = False
        self.in_msg = False
        self.data = dict()
        
    def startElement(self, name, attributes):
        if name == "logentry":
            self.entry.revision = int(attributes['revision'])
        elif name == "author":
            self.in_author = True
        elif name == "date":
            self.in_date = True
        elif name == "msg":
            self.in_msg = True
        return
    
    def characters(self, data):
        if self.in_author:
            self.entry.author = data
        if self.in_date:
            self.entry.date = data
        if self.in_msg:
            self.entry.msg = data;

    def endElement(self, name):
        if name == "author":
            self.in_author = False
        if name == "date":
            self.in_date = False
        if name == "msg":
            self.in_msg = False
        
        if name == "logentry":
            self.data[self.entry.revision] = self.entry
            self.entry = svnLogEntry()

#
# Format results from 'svn status --xml'.
#
# <entry path="path/to/file">
#   <wc-status props="none" item="modified" revision="332">
#     <commit revision="331">
#       <author>mitch</author>
#       <date>2008-08-14T21:59:18.968863Z</date>
#     </commit>
#   </wc-status>
# </entry>
#
class svnStatusXmlHandler(xml.sax.handler.ContentHandler):      
    def __init__(self):
        self.entry = svnStatusEntry()
        self.data = dict()
        
    def startElement(self, name, attributes):
        if name == "entry":
            self.entry.path = attributes['path']
        elif name == "wc-status":
            self.entry.action = attributes['item']
            try:
                self.entry.revision = attributes['revision']
            except:
                self.entry.revision = "?"
                pass
    
    def endElement(self, name):
        if name == "entry":
            self.data[self.entry.path] = self.entry
            self.entry = svnStatusEntry()
            
        
##################################################################################################
## Main code
##################################################################################################

TREE_TOP = "."

def usage():
    sys.stderr.write("s4 syntax:\n")
    sys.stderr.write("\n")
    sys.stderr.write("   s4 add [<path>]\n")
    sys.stderr.write("   s4 changes [-u <user>] [-m <number>]\n")
    sys.stderr.write("   s4 describe [-s] <changeset>\n")
    sys.stderr.write("   s4 diff [ \"...\" | <path>]\n")
    sys.stderr.write("   s4 filelog <path>]\n")
    sys.stderr.write("   s4 opened [ \"...\" | <path>]\n")
    #sys.stderr.write("   s4 submit [<path>]\n")
    sys.stderr.write("   s4 sync [<path>]\n")
    
    sys.exit(2)

#
# Given an optstring (e.g., ab:c:d), return an array containing two things:
#
# 1. A dictionary containing only the keys that were present in the input string, and the values of
#    the keys are either 'True' for present with no arguments or a string containing the value for 
#    that option.
#
# 2. The 'args' array of any suffix arguments that were not matched.
#
def parse_opts(optstr, cmd_argv):
    d = dict()

    try:
        opts, args = getopt.getopt(cmd_argv, optstr)
    except getopt.GetoptError:
        usage() # XXX could improve this quite a bit
        output = None
    for o, a in opts:
        if a == None:
            d[o] = True
        else:
            d[o] = a

    return [d, args]

#
# Runs an svn command and returns the result lines in a list.
#
def popen_svn(cmd_str):
    p = os.popen(cmd_str)
    lines = []
    count = 0
    while 1:
        line = p.readline()
        if not line:
            break
        lines.append(line)
        count = count + 1
        if count > 100:
            count = 0
            # OK this counter is confusing because the number is the lines of XML which 
            # really doesn't mean much, but it's SOMETHING anyway.
            sys.stderr.write("\rworking (" + str(len(lines)) + ")...")
            sys.stderr.flush()
    p.close() # XXX check return code 
    sys.stderr.write("\r")
    sys.stderr.flush()
    return lines

#
# Helper for 'changes' and 'filelog' commands
#
def svn_log(limit_str, path, flags = dict(), user_limit = 0):
    lines = popen_svn("svn log --xml " + limit_str + " " + path)

    parser = xml.sax.make_parser()
    handler = svnLogXmlHandler()
    xml.sax.parseString("".join(lines), handler)
    
    revs = handler.data.keys()
    revs.sort()
    revs.reverse()
    user_count = 0 # For when -m and -u are used together
    for rev in revs:
        entry = handler.data[rev]

        print_this = False
        if flags.has_key('-u'):
            if entry.author == flags['-u']:
                user_count = user_count + 1
                print_this = True
        else:
            print_this = True
        
        if print_this:
            date_str = str(entry.date)
            if date_str.index('T'):
                date_str = date_str[:date_str.index('T')]
            line_str = "Change " + str(rev) \
                     + " on " + date_str \
                     + " by " + str(entry.author) \
                     + " '" + str(entry.msg)
            line_str = line_str[:78] # force it to fit into 80 columns
            line_str = line_str.replace('\n', ' ') # any spurious newlines get chopped
            line_str = line_str + "'"
            print line_str
        
        if user_limit > 0:
            if user_count >= user_limit:
                print "break!"
                break
    return   

#
# 'describe' command
#
# s4 describe [-s] <changeset>
#
def do_describe(cmd_argv):
    cmd_opts = parse_opts("s", cmd_argv)
    flags, args = cmd_opts

    if len(args) == 0:
        sys.stderr.write("Error: no changeset provided\n")
        sys.exit(2)
    if len(args) != 1:
        sys.stderr.write("Error: only one changeset at a time, please\n")
        sys.exit(2)

    try:
        cs = int(args[0])
    except:
        sys.stderr.write("Error: changeset must be a number\n")
        sys.exit(2)
    
    prev_cs = cs - 1
    if prev_cs <= 0:
        sys.stderr.write("Error: need to handle changeset 1 in a special manner\n")
        sys.exit(2)
    
    diff_args = ""
    if flags.has_key('-s'):
        # 'short' version
        os.system("svn log -r" + str(cs))    
        # Here, we assume 'diff' is not a graphical differ.
        diff_args = "--diff-cmd diff -r" + str(prev_cs) + ":" + str(cs) + " --summarize"
    else:
        diff_args = "-r" + str(prev_cs) + ":" + str(cs)
    os.system("svn diff " + diff_args)
    return

#
# 'filelog' command
#
# s4 filelog <path/file>
#
def do_filelog(cmd_argv):
    if len(cmd_argv) == 0:
        arg_str = TREE_TOP
    elif cmd_argv[0] == "...":
        arg_str = " -R ."
    else:
        arg_str = " ".join(cmd_argv)

    svn_log("", arg_str)
    return

#
# 'changes' command
#
# s4 changes [-u <username>] [-m <number of lines to show>] [path/file]
#
# Unfortunately, using -u (which we do locally) and -m means we need to get all of the history
# to be sure we get -m lines of -u. :-(  So...
# 
def do_changes(cmd_argv):
    cmd_opts = parse_opts("u:m:", cmd_argv)
    flags, args = cmd_opts

    # No file args?  Use the top of the tree.
    if len(args) == 0:
        args.append(TREE_TOP)
    if len(args) != 1:
        sys.stderr.write("Error: only one path supported\n")
        sys.exit(2)
        
    user_limit = 0
    
    path = args[0]
    limit_str = ""
    if flags.has_key('-m'):
        try:
           limit = int(flags['-m'])
        except:
           sys.stderr.write("Error: -m requires a number.\n")
           sys.exit(3)

        if not flags.has_key('-u'):
            limit_str = "--limit " + str(limit)
        else:
            user_limit = limit

    svn_log(limit_str, path, flags, user_limit)
    return

#
# 'diff' command
#
# s4 diff [ "..." | <path> ]
#
# Bare bones for now
#
def do_diff(cmd_argv):
    arg_str = None
    if len(cmd_argv) == 0:
        arg_str = TREE_TOP
    elif cmd_argv[0] == "...":
        arg_str = "."
    else:
        arg_str = " ".join(cmd_argv)

    os.system("svn diff " + arg_str)
    return

#
# 'sync' command
#
# s4 sync [ "..." | <path> ]
#
# Bare bones for now
#
def do_sync(cmd_argv):
    arg_str = None
    if len(cmd_argv) == 0:
        arg_str = TREE_TOP
    elif cmd_argv[0] == "...":
        arg_str = "."
    else:
        arg_str = " ".join(cmd_argv)

    os.system("svn update " + arg_str)
    return

#
# 'add' command
#
# s4 add [ "..." | <path> ]
#
def do_add(cmd_argv):
    arg_str = ""
    if len(cmd_argv) == 0:
        sys.stderr.write("Missing arguments to add\n")
        sys.exit(1)
    # XXX Can't remember the exact add syntax right now.  I know p4 add path/... should work.
    #elif cmd_argv[0] == "..."
    #    arg_str
    else:
        arg_str = " ".join(cmd_argv)

    # XXX This is just a place holder for the moment.  There needs to be a generic handler for ... paths.

    os.system("svn add " + arg_str)
    return

#
# 'submit' command
#
# s4 submit [ <path> ]
#
# Unlike the default Subversion, this submits from the top of the workspace.
#
def do_submit(cmd_argv):
    print "not yet implemented"
    pass
    

#
# 'opened' command
#
# s4 opened [ "..." | <path> ]
#
# Since Subversion doesn't have a notion of editing a file in the same that Perforce does, this
# just shows the 'svn status' output
#
def do_opened(cmd_argv):
    arg_str = None
    if len(cmd_argv) == 0:
        arg_str = TREE_TOP
    elif cmd_argv[0] == "...":
        arg_str = "."
    else:
        arg_str = " ".join(cmd_argv)

    lines = popen_svn("svn status --xml " + arg_str)
    
    parser = xml.sax.make_parser()
    handler = svnStatusXmlHandler()
    xml.sax.parseString("".join(lines), handler)

    paths = handler.data.keys()
    paths.sort()
    TREE_TOP_LEN = len(TREE_TOP)
    for path in paths:
        entry = handler.data[path]
        
        subpath = path
        if subpath.startswith(TREE_TOP):
            subpath = subpath[TREE_TOP_LEN:]
        print subpath + "#" + str(entry.revision) + " - " + entry.action
    return        

def main():
    if len(sys.argv) == 1:
        usage()
    
    if os.environ.has_key(TOP_VARIABLE):
        global TREE_TOP
        TREE_TOP = os.environ[TOP_VARIABLE]
        if not os.path.exists(TREE_TOP):
            sys.stderr.write("Error: $" + TOP_VARIABLE + " is not a valid path\n")
            sys.exit(1)
        #
        # XXX need to check and make sure we're inside of $(TREE_TOP) 
        #
    else:
        sys.stderr.write("Error: Missing $" + TOP_VARIABLE + " in environment\n")
        sys.exit(1) 

    cmd = sys.argv[1]
    cmd_args = []
    if len(sys.argv) > 2:
        cmd_args = sys.argv[2:]
    # 
    # XXX This dispatch "table" needs some help.
    #
    if cmd == "describe":
        do_describe(cmd_args)
    elif cmd == "changes":
        do_changes(cmd_args)
    elif cmd == "diff":
        do_diff(cmd_args)
    elif cmd == "opened":
        do_opened(cmd_args)
    elif cmd == "sync":
        do_sync(cmd_args)
    elif cmd == "add":
        do_add(cmd_args)
    elif cmd == "filelog":
        do_filelog(cmd_args)
    else:
        sys.stderr.write("Unsupported command.\n")
        sys.exit(2)
if __name__ == "__main__":
    main()
